[16.0][IMP] queue_job: defer job execution across rolling deployments and module installs#953
[16.0][IMP] queue_job: defer job execution across rolling deployments and module installs#953baguenth wants to merge 2 commits into
Conversation
In a multi-process or multi-host deployment, the jobrunner's HTTP dispatch can land on any worker, including one that hasn't yet picked up a module upgrade another worker already completed (e.g. mid rolling-deployment). Running a job in that state risks acting on stale code/files that no longer match the database. Before performing a job, check whether any installed module's on-disk version (read fresh from this process, via the already lru_cached get_manifest()) differs from what the database recorded as installed during the most recent successful upgrade (ir.module.module.latest_version). On a mismatch, defer the job via the existing postpone/requeue path instead of executing it - this runs before job.perform(), so it never consumes a retry attempt and can safely wait out an arbitrarily long rollout. The result is cached briefly per database to avoid re-querying ir.module.module on every single job dispatch. Configurable via the queue_job.check_modules_up_to_date system parameter (enabled by default).
6a67293 to
8d63ebc
Compare
…d/upgraded Core already skips scheduled actions while any module sits in 'to install'/'to upgrade'/'to remove' state (ir_cron._check_modules_state), since that's exactly the window where module state and code may not agree across the cluster yet. queue_job's HTTP-based dispatch bypasses ir_cron entirely and had no equivalent guard. This matters because a job committed early in an install (e.g. from a post_init_hook) can become visible and dispatchable to other workers before the module that created it is actually marked installed -- the previous _worker_is_outdated() check has nothing to compare against in that window, since latest_version isn't written until the install completes. Root cause of OCA#882. Ports the same derived-state guard used by ir_cron: while any module is pending install/upgrade/removal, defer the job (same postpone-and-retry mechanism as the outdated-worker check), with a staleness escape for abandoned pending states and an opt-out system parameter.
|
Thanks for proposing this PR. I have been thinking about this and I'm not quite convinced this is queue_job's responsibility to care about such deployment issues. For instance, if during a rolling deployment you have different versions running at the same time on the same database, a similar problem happens with interactive or API requests. Why are queue jobs special in that respect? Have you considered, for instance, running the queue_job scheduler in a different pod and pausing it during deployments? This can be done with something like https://github.com/OCA/queue/pull/409/changes |
|
I've done blue/green rolling Kubernetes deployments at previous companies, but the Odoo ORM is not set up to handle an inconsistent database structure. This is a non-starter for me, sorry. |
|
Thanks for your feedback. I also had mixed feelings about whether this should be handled by The reason I focused on queue jobs is that they are often triggered as part of the update process itself. Even if deployments happen during maintenance windows where interactive users are not an issue, queue jobs are still likely to run because of the update. That said, I agree this is based on an assumption. Other requests (API calls, cron jobs, external systems, etc.) can still hit Odoo during a rolling deployment, so queue jobs are not really unique in that regard. I completely respect if this PR gets declined, even though it would solve the specific issue we are facing. A possible middle ground could be to make these checks opt-in, but I can also understand if the preference is to keep this responsibility entirely outside of |
|
Thank you for your understanding. I'm going to close this as out of scope. |
Problem
queue_job's jobrunner dispatches pending jobs by issuing an HTTP request to theinstance's configured URL. In a single-process setup that naturally comes back to the
same process, but in any deployment with more than one worker process behind a load
balancer or reverse proxy — for example, multiple pods or containers in a
container-orchestrated setup (Kubernetes, Docker Swarm, or similar) — that URL resolves
to whichever worker happens to be sitting behind the load balancer at that moment. Not
necessarily the worker that enqueued the job, and not necessarily one running the same
code, or even one that agrees with the database on which modules are currently
installed.
Rolling deployment: worker running stale code
Worker restarts aren't atomic across a fleet: pods/containers get replaced one at a
time, so for some window there are old and new versions running side by side, all
reachable through the same load balancer. The trigger is often the module upgrade
itself: it's a common pattern for a module's upgrade step (a
post_init_hook, or acall in a data file) to enqueue a job via
with_delay()for post-processing thatshouldn't run synchronously inside the upgrade transaction — resyncing data, warming
caches, per-record post-processing, and similar. That job is created as part of the
upgrade, by the worker performing it, which is precisely what sets up the race. A
concrete timeline:
database, both reachable behind the same load balancer.
runs the module upgrade (
-u) against the shared database. As one of its upgradesteps, it enqueues a post-processing job via
with_delay().unaware anything changed.
the load balancer.
1.1 has already migrated.
What happens next depends entirely on what that job does — there is no single,
predictable failure mode:
worker — noisy, but at least visible.
reads something version-dependent — a file bundled with the module, a piece of
business logic that changed for the 1.1 release — it can complete "successfully"
while producing outdated or subtly wrong results. Nothing raises, nothing logs an
error, the job reports
done. This case is the more dangerous one: it isn't caughtby retries or monitoring, and it can go unnoticed until a symptom shows up somewhere
else entirely, or until the next upgrade re-triggers the same job and it behaves
differently (and correctly) the second time.
Both outcomes share the same root cause: the worker executing the job is not
guaranteed to be running the same code as the worker(s) that already updated the
database. This affects any job whose behavior is coupled to the module's own code or
bundled files, which
queue_jobhas no way to know about or guard against on its own —and post-processing jobs enqueued directly from an upgrade step are the most common way
to run straight into it.
A narrower, earlier window: jobs enqueued during the install itself
There's a second, related window that a version check alone can't cover, and it's the
root cause of #882. Core's own module loading
(
odoo/modules/loading.py::load_module_graph()) commits a module'spost_init_hookbefore it commits that module's own
state = 'installed'/latest_versionupdate. Concretely, for each module being installed/upgraded in a batch: the hook runs
and its commit fires, and only after that (sometimes as part of the next module's
commit, sometimes only at the very end of the whole request if it's the last module in
the batch) does the module get marked installed with its new
latest_version.If that hook enqueues a job — the same pattern described above — the job can already be
committed and dispatchable to any worker while the database doesn't yet record the
module as installed at all. A version-mismatch check has nothing to compare against in
that window:
latest_versionsimply hasn't been written yet, so the job can reach aworker that has no way to tell it's running against a database still mid-upgrade.
Core already guards against exactly this shape of problem for its own scheduled actions
(
ir_cron._check_modules_state(),odoo/addons/base/models/ir_cron.py): while any module sits in'to install'/'to upgrade'/'to remove'state, cron processing is skipped for the wholedatabase, with a staleness escape for abandoned pending states.
queue_jobdispatchesover HTTP and bypasses
ir_cronentirely, so it never inherited this protection.Proposed fix
Two independent checks in
RunJobController._runjob, run before a job is performed,covering the full timeline with an atomic handover between them:
1. Worker running outdated module code
Compare each installed module's on-disk version — read fresh from this process via
get_manifest()(alreadylru_cached in core, so this is cheap after the first call)— against what the database recorded as the installed version during the most recent
successful upgrade (
ir.module.module.latest_version, written byodoo/modules/loading.pyevery time a module is actually installed/upgraded by someprocess).
A mismatch means some other worker has already upgraded this module past what the
current worker is running.
2. Install/upgrade/removal in progress anywhere in the cluster
Port of core's
ir_cron._check_modules_state(): while any module'sir_module_module.stateis'to install','to upgrade', or'to remove', defer thejob — this is exactly the window described above, where a job may already be committed
and visible while the database hasn't finished recording what it belongs to. Pending
states older than a configurable timeout are treated as abandoned (e.g. a process
crashed mid-install) and ignored, with a warning logged, rather than blocking job
processing forever.
The two checks hand off cleanly: the same commit that clears the last pending module
state also publishes its
latest_version, so the instant the install-in-progress checkdrops, the version check has trustworthy data for any worker still running stale code.
In both cases, the job is deferred through the existing postpone/requeue path instead
of being executed — the checks run before
job.perform(), so they never consume aretry attempt and can safely wait out a rollout or install of any length. They resolve
automatically once the relevant worker is replaced/restarted or the install completes,
or sooner if a retry happens to land on a worker/state that's already current.
The version-mismatch verdict is cached briefly per database so a busy queue isn't
re-querying
ir.module.moduleon every single job dispatch; the install-in-progresscheck is intentionally not cached, since it needs to catch a job committed in the
same transaction as the pending state — a cached verdict would reopen that exact race
for the length of the cache window. Detecting an active install also busts the
version-mismatch cache, so the first job dispatched once the install completes
re-checks fresh instead of reusing a pre-install verdict.
Configuration
queue_job.check_modules_up_to_date(default enabled) — the worker-version check.Set to
Falseto disable.queue_job.check_install_in_progress(default enabled) — the install-in-progresscheck. Set to
Falseto disable.queue_job.install_in_progress_timeout_minutes(default60) — how long a pendingmodule state is trusted before being treated as abandoned.
Tests
Added coverage for: default (not outdated / nothing pending) behavior, a forced version
mismatch, a module pending install/upgrade/removal, graceful handling of a module whose
manifest can't be read (never treated as evidence of staleness), an abandoned pending
state past the timeout, both config-parameter opt-outs, the short-lived version-check
cache (and its busting by the install-in-progress check), and all three branches of
_runjob(install-in-progress deferral, outdated-worker deferral, normal execution).Related
Closes/addresses #882.